03-cluster.ipynb
Download Notebook

Data Masters Case: Agrupamentos Naturais

Felipe Viacava – São Paulo, ago/2023

O presente documento consiste no desenvolvimento de modelos de agrupamento como parte da solução do Case “Data Masters - Cientista de Dados” do Santander Brasil.

O objetivo é identificar e avaliar agrupamentos naturais (clusters), e atribuí-los a um rank baseado no lucro esperado por cliente.

Na etapa de classificação, buscava-se maximizar o lucro total que um modelo preditivo poderia gerar ao banco numa campanha de retenção. Agrupamentos naturais, por outro lado, são criados de forma não supervisionada, de modo que não podemos utilizar falsos positivos e verdadeiros positivos encontrados nos modelos para avaliá-los, uma vez que não existe uma variável “TARGET” para determinar estas métricas.

Premissas adotadas nesta etapa:

Bibliotecas

In [1]:
# --- Data Exploration and Viz --- #
import pandas as pd
import plotly.express as px
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.preprocessing import StandardScaler
from resources.edautils import neg_pos_zero
from resources.customviz import expl_var

# --- Data Preprocessing --- #
import numpy as np

# --- Pipelines --- #
from resources.prep import \
    build_prep, \
    build_prep_3

from sklearn.cluster import KMeans, DBSCAN
from sklearn.neighbors import NearestNeighbors

# --- Model Loading --- #
import pickle

Lucro estimado

Uma das vantagens de trabalhar com classes para criar modelos robustos é a portabilidade. Não apenas para fins de deployment, mas também para agilidade em seu uso para diferentes aplicações. Aqui, lemos o conjunto de testes e carregamos o modelo campeão previamente treinado para recriar a coluna de lucro esperado por cliente.

In [2]:
Mostrar/esconder código
with open("models/hgb.pkl", "rb") as f:
    hgb = pickle.load(f)

test = pd \
    .read_csv('data/test.csv') \
    .assign(
        predicted = (
            lambda ldf:
            hgb.predict(ldf.drop("TARGET", axis=1))
        ),
        profit = (
            lambda ldf:
            ((ldf["TARGET"] * 100) - 10) * ldf["predicted"]
        ),
        origin = "test"
    )

train = pd \
    .read_csv('data/train.csv') \
    .assign(
        predicted = np.nan,
        profit = np.nan,
        origin = "train"
    )

df = pd.concat([train, test]).reset_index(drop=True)

reference = df[["ID", "TARGET", "predicted", "profit", "origin"]]

pd.concat([reference.head(3), reference.tail(3)]).head(6)
ID TARGET predicted profit origin
0 113911 0 NaN NaN train
1 120462 0 NaN NaN train
2 87126 0 NaN NaN train
76017 138601 1 0.0 0.0 test
76018 78655 0 1.0 -10.0 test
76019 130139 0 1.0 -10.0 test

Processamento

Os passos de pré-processamento dos dados utilizados no modelo campeão serão reutilizados aqui, com exceção dos encoders ordinais. Na classificação, foram usados enconders ordinais para evitar o aumento de dimensionalidade, reduzindo o número de variáveis aleatórias necessárias por split nas árvores, uma vez que lidam bem com relações não lineares entre variáveis independentes e a variável target. No caso da análise de clusters, foi escolhido o One Hot Encoding para as features categóricas, além de outras manipulações para as variáveis numéricas.

In [3]:
Mostrar/esconder código
pdf = df.drop(["TARGET","predicted","profit","origin"], axis=1)
prep = build_prep()[:-2].fit(pdf)
pdf = prep.transform(pdf)
prep
Pipeline(steps=[('DropConstantColumns', DropConstantColumns(also=['ID'])),
                ('DropDuplicateColumns', DropDuplicateColumns()),
                ('NoneZeroCountSaldo', AddNonZeroCount(prefix='saldo')),
                ('SumSaldo', CustomSum(prefix='saldo')),
                ('NoneZeroCountImp', AddNonZeroCount(prefix='imp')),
                ('SumImp', CustomSum(prefix='imp')),
                ('ImputeNanDelta',
                 CustomImputer(prefix='delta', to...99999)),
                ('NoneCountDelta', AddNoneCount(prefix='delta')),
                ('NonZeroCountDelta', AddNonZeroCount(prefix='delta')),
                ('SumDelta', CustomSum(prefix='delta')),
                ('NonZeroContInd', AddNonZeroCount(prefix='ind')),
                ('NonZeroCountNum', AddNonZeroCount(prefix='num')),
                ('SumNum', CustomSum(prefix='num')),
                ('ImputeNanVar3',
                 CustomImputer(prefix='var3', to_replace=-999999))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
In [4]:
Mostrar/esconder código
npz = neg_pos_zero(pdf, list(pdf.columns))
npz \
    .assign(
        zero_zone = lambda ldf: ldf["Zero values (%)"].apply(lambda lr: lr//10 * 10)
    ) \
    [["zero_zone", "Column"]] \
    .groupby("zero_zone") \
    .count() \
    .rename(mapper={"Column": "Number of Columns"}, axis=1)
Number of Columns
zero_zone
0.0 12
10.0 5
20.0 10
30.0 7
50.0 1
60.0 4
70.0 4
80.0 26
90.0 247
In [5]:
remaining = [
    col 
    for col in pdf.columns 
    if ((pdf[col]==0).sum()/pdf.shape[0] < .4)
]
remaining
['var3',
 'var15',
 'ind_var5_0',
 'ind_var5',
 'ind_var30_0',
 'ind_var30',
 'ind_var39_0',
 'ind_var41_0',
 'num_var4',
 'num_var5_0',
 'num_var5',
 'num_var30_0',
 'num_var30',
 'num_var35',
 'num_var39_0',
 'num_var41_0',
 'num_var42_0',
 'num_var42',
 'saldo_var5',
 'saldo_var30',
 'saldo_var42',
 'var36',
 'num_meses_var5_ult3',
 'num_meses_var39_vig_ult3',
 'saldo_medio_var5_hace2',
 'saldo_medio_var5_hace3',
 'saldo_medio_var5_ult1',
 'saldo_medio_var5_ult3',
 'var38',
 'non_zero_count_saldo',
 'sum_of_saldo',
 'non_zero_count_ind',
 'non_zero_count_num',
 'sum_of_num']
In [6]:
Mostrar/esconder código
ss = StandardScaler()

pdft = pd.concat(
    [
        pd.DataFrame(
            ss.fit_transform(pdf),
            columns=pdf.columns
        ),
        reference
    ],
    axis=1
)

long_df = pdft \
    .melt(
        id_vars="TARGET",
        value_vars=remaining,
        var_name="variable",
        value_name="value"
    )

plt.figure(figsize=(10, 30))
sns.violinplot(
    long_df,
    x="value",
    y="variable",
)
plt.show()

Processamento: PCA

In [7]:
Mostrar/esconder código
cdf = df.drop(["TARGET","predicted","profit","origin"],axis=1)
prep = build_prep_3().fit(cdf)
tdf = pd.concat(
    [
        pd.DataFrame(prep.transform(cdf)),
        reference
    ],
    axis=1
)
prep
Pipeline(steps=[('prep',
                 Pipeline(steps=[('DropConstantColumns',
                                  DropConstantColumns(also=['ID'])),
                                 ('DropDuplicateColumns',
                                  DropDuplicateColumns()),
                                 ('NoneZeroCountSaldo',
                                  AddNonZeroCount(prefix='saldo')),
                                 ('SumSaldo', CustomSum(prefix='saldo')),
                                 ('NoneZeroCountImp',
                                  AddNonZeroCount(prefix='imp')),
                                 ('SumImp', CustomSum(prefix='imp')),
                                 ('ImputeNanDelta',
                                  CustomI...
                                    'saldo_medio_var5_ult1',
                                    'saldo_medio_var5_ult3', 'num_var42_0',
                                    'sum_of_saldo', 'var38', 'sum_of_num',
                                    'non_zero_count_num',
                                    'non_zero_count_ind'])),
                ('cat',
                 ColumnTransformer(remainder='passthrough',
                                   transformers=[('ohe',
                                                  Pipeline(steps=[('ohe',
                                                                   OneHotEncoder(min_frequency=100,
                                                                                 sparse_output=False))]),
                                                  ['var36'])])),
                ('ss', StandardScaler()), ('knn', KNNImputer()),
                ('pca', PCA())])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
In [8]:
Mostrar/esconder código
expl_var(prep[-1].explained_variance_ratio_)

80% of variance is explained by 9 components
In [9]:
Mostrar/esconder código
long_df = tdf \
    .melt(
        id_vars="TARGET",
        value_vars=[0, 1, 2],
        var_name="variable",
        value_name="value"
    )

sns.violinplot(
    long_df,
    x="variable",
    y="value",
)
plt.show()

In [10]:
Mostrar/esconder código
px.scatter_3d(
    tdf.assign(TARGET=tdf["TARGET"].astype("category")),
    x=0,
    y=1,
    z=2,
    color="TARGET",
    opacity=.1
)

Agrupamentos com KMeans

In [11]:
Mostrar/esconder código
ssd = []
for num_clusters in range(1, 31):
    kmeans = KMeans(n_clusters=num_clusters, random_state=42, n_init='auto')
    kmeans.fit(tdf[[i for i in range(0,10)]])
    ssd.append(kmeans.inertia_)


plt.figure(figsize=(10,6))
plt.plot(range(1, 31), ssd, marker='o', linestyle='--')
plt.xlabel('Number of Clusters')
plt.ylabel('Sum of Squared Distances')
plt.title('Elbow Method For Optimal Number of Clusters')
plt.show()

In [12]:
Mostrar/esconder código
kmeans = KMeans(n_clusters=9, random_state=42, n_init='auto')
clusters = kmeans.fit_predict(tdf[[i for i in range(0,10)]])
tdf["kmeans"] = clusters

px.scatter_3d(
    tdf.assign(kmeans=tdf["kmeans"].astype("category")),
    x=0,
    y=1,
    z=2,
    color="kmeans",
    opacity=.1
)
In [13]:
Mostrar/esconder código
gdf = tdf[["kmeans", "profit", "TARGET"]] \
    .groupby(["kmeans"]) \
    .agg(["mean", "sum", "count"])

gdf.columns = [
    "Average Profit",
    "Total Profit",
    "Number of Customers (Test)",
    "drop0",
    "drop1",
    "Number of Customers (Total)"
]

gdf \
    .drop(["drop0", "drop1"], axis=1) \
    .sort_values("Average Profit", ascending=False)
Average Profit Total Profit Number of Customers (Test) Number of Customers (Total)
kmeans
5 4.137623 4630.0 1119 4454
4 2.860114 11000.0 3846 15372
7 1.495935 920.0 615 2423
8 0.550459 60.0 109 406
6 0.425601 1310.0 3078 12306
1 0.104572 780.0 7459 29807
0 0.000000 0.0 1318 5465
2 0.000000 0.0 419 1633
3 -0.038388 -40.0 1042 4154